<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ClickHouse Parts Visualizer</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <style>
        body {
            font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
            padding: 1rem;
        }

        input#url, textarea#query {
            border: 3px solid #EEE;
            font-size: 12pt;
            padding: 0.25rem;
        }

        textarea#query {
            height: 82px;
        }

        input#play {
            font-size: 16pt;
            padding: 0;
        }

        #url {
            width: 80%;
        }
        #user, #password {
            width: 10%;
        }
        #query {
            width: 100%;
            height: 3rem;
        }

        input.my-button[type="button"] {
            background: #FED;
            width: 2rem;
            height: 2rem;
        }
        input.my-button[type="button"]:hover {
            background: #F88;
            cursor: pointer;
        }

        #canvas {
            margin-top: 0.25rem;
            font-size: 10pt;
        }

        .tooltip {
            position: absolute;
            text-align: left; /* Align text to the left for better readability */
            padding: 10px;
            font: 12px sans-serif;
            background: rgba(0, 0, 0, 0.8);
            color: #fff;
            border-radius: 4px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.3s;
            box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
        }

        .tooltip table {
            border-collapse: collapse;
        }

        .tooltip td {
            padding: 4px 8px;
        }

        .tooltip tr:nth-child(even) {
            background-color: rgba(255, 255, 255, 0.1);
        }
    </style>
</head>
<body>
<div class="inputs">
    <form id="params">
        <div id="connection-params">
            <input spellcheck="false" id="url" type="text" value="http://localhost:8123" placeholder="URL" /><input spellcheck="false" id="user" type="text" value="" placeholder="user" /><input spellcheck="false" id="password" type="password" placeholder="password" value="" />
            <input id="hidden-submit" type="submit" hidden="true"/>
        </div>
        <textarea spellcheck="false" data-gramm="false" id="query" rows="10">SELECT * FROM system.parts WHERE active = 1 AND database != 'system' ORDER BY database, table, partition, min_block_number</textarea>
        <input class="my-button" id="play" type="button" value="▶">
        <div id="query-error" style="color: red; margin-top: 10px; display: none;"></div>
    </form>
</div>
<div id="canvas">
</div>
<script type="module">

function determineTickStep(max_block_number) {
    let step;
    let unit;

    if (max_block_number >= Math.pow(1000, 3)) // For G range
        unit = Math.pow(1000, 3); // 1 G
    else if (max_block_number >= Math.pow(1000, 2)) // For M range
        unit = Math.pow(1000, 2); // 1 M
    else if (max_block_number >= 1000) // For K range
        unit = 1000; // 1 K
    else
        unit = 1; // range

    // Determine appropriate tick step based on the range (1, 2, 5, 10, 20, 50, 100, 200...)
    if (max_block_number / unit > 500)
        step = 100 * unit;
    else if (max_block_number / unit > 200)
        step = 50 * unit;
    else if (max_block_number / unit > 100)
        step = 20 * unit;
    else if (max_block_number / unit > 50)
        step = 10 * unit;
    else if (max_block_number / unit > 20)
        step = 5 * unit;
    else if (max_block_number / unit > 10)
        step = 2 * unit;
    else
        step = 1 * unit;

    return step;
}

function formatNumber(number) {
    if (number >= Math.pow(1000, 4))
        return (number / Math.pow(1000, 4)).toFixed(1) + 'T';
    else if (number >= Math.pow(1000, 3))
        return (number / Math.pow(1000, 3)).toFixed(1) + 'G';
    else if (number >= Math.pow(1000, 2))
        return (number / Math.pow(1000, 2)).toFixed(1) + 'M';
    else if (number >= 1000)
        return (number / 1000).toFixed(1) + 'K';
    else
        return number;
}

function formatBytes(bytes) {
    const digits = 0;
    if (bytes >= Math.pow(1024, 4))
        return (bytes / Math.pow(1024, 4)).toFixed(digits) + 'TB';
    else if (bytes >= Math.pow(1024, 3))
        return (bytes / Math.pow(1024, 3)).toFixed(digits) + 'GB';
    else if (bytes >= Math.pow(1024, 2))
        return (bytes / Math.pow(1024, 2)).toFixed(digits) + 'MB';
    else if (bytes >= 1024)
        return (bytes / 1024).toFixed(digits) + 'KB';
    else
        return bytes;
}

function getFormatter(selector) {
    switch (selector) {
        case "level":
        case "blocks":
        case "rows":
            return formatNumber;
        case "bytes_on_disk":
        case "data_compressed_bytes":
        case "data_uncompressed_bytes":
            return formatBytes;
        default:
            return d3.format(",");
    }
}

function visualizeMergeTree(title, raw_data) {
    let titleContainer = d3.select("body")
        .append("div")
        .attr("class", "container-fluid bg-primary text-white")
        .text(title);

    let selectorsContainer = d3.select("body")
        .append("div")
        .attr("class", "container mt-3")
        .append("form")
        .attr("class", "d-flex align-items-center")

    let container = d3.select("body")
        .append("div")
        .attr("class", "container-fluid")
        .append("div")

    // Input visuals
    const margin = { left: 50, right: 30, top: 10, bottom: 50 };
    const width = container.node().clientWidth - margin.left - margin.right;
    const height = 300;
    const part_dy = 4;
    const part_dx = 4;

    // Set initial selector values
    let xSelector = "blocks";
    let leftSelector = "left_blocks";
    let rightSelector = "right_blocks";
    let ySelector = "bytes_on_disk";
    let colorSelector = "level";

    // Compute useful aggregates
    function computeXAggregates(data, leftSelector, rightSelector) {
        let minXValue = d3.min(data, d => d[leftSelector]);
        let maxXValue = d3.max(data, d => d[rightSelector]);
        return { minXValue, maxXValue };
    }
    function computeYAggregates(data, ySelector) {
        let minYValue = d3.min(data, d => d[ySelector]);
        let maxYValue = d3.max(data, d => d[ySelector]);
        return { minYValue, maxYValue };
    }
    function computeColorAggregates(data, colorSelector) {
        let minColorValue = d3.min(data, d => d[colorSelector]);
        let maxColorValue = d3.max(data, d => d[colorSelector]);
        return { minColorValue, maxColorValue };
    }

    // Prepare Data
    let totals = {
        bytes_on_disk: 0,
        data_compressed_bytes: 0,
        data_uncompressed_bytes: 0,
        blocks: 0,
        rows: 0,
    };
    let data = raw_data.map(row => {
        row.blocks = +row.max_block_number - +row.min_block_number + 1;
        totals.blocks = +row.min_block_number; // In case of skipped blocks
        let lefts = {};
        let rights = {};
        let values = {};
        for (const k in totals) {
            const value = +row[k];
            lefts[`left_${k}`] = totals[k];
            totals[k] += value;
            values[k] = value;
            rights[`right_${k}`] = totals[k];
        }
        return {
            name: row.name,
            modification_time: row.modification_time,
            part_type: row.part_type,
            level: +row.level,
            marks: +row.marks,
            marks_bytes: +row.marks_bytes,
            primary_key_size: +row.primary_key_size,
            ...lefts,
            ...rights,
            ...values,
        };
    });

    let { minXValue, maxXValue } = computeXAggregates(data, leftSelector, rightSelector);
    let { minYValue, maxYValue } = computeYAggregates(data, ySelector);
    let { minColorValue, maxColorValue } = computeColorAggregates(data, colorSelector);

    console.log([minXValue, maxXValue], [minYValue, maxYValue]);

    const svgWidth = width + margin.left + margin.right;
    const svgHeight = height + margin.top + margin.bottom;

    // Set up the horizontal scale (x-axis) — linear scale
    let xScale = d3.scaleLinear()
        .domain([minXValue, maxXValue])
        .range([margin.left, margin.left + width]);

    // Set up the vertical scale (y-axis) — logarithmic scale
    let yScale = d3.scaleLog()
        .base(2)
        .domain([Math.max(1, minYValue), Math.pow(2, Math.ceil(Math.log2(maxYValue)))])
        .range([svgHeight - margin.bottom, margin.top]);

    // Set up color scale
    let colorScale = d3.scaleSequential(d3.interpolateViridis)
        .domain([minColorValue, maxColorValue]);

    // Create the SVG container
    const svgContainer = container
        .append("svg")
        .attr("width", svgWidth)
        .attr("height", svgHeight);

    // To avoid negative width and height
    function pxl(value) { return value; }
    function pxr(value) { return Math.max(1, value); }
    function pxt(value) { return value; }
    function pxb(value) { return Math.max(1, value); }

    // Clip path to avoid drawing outside the chart area
    svgContainer.append("defs").append("clipPath")
        .attr("id", "clip")
        .append("rect")
        .attr("x", margin.left)
        .attr("y", margin.top)
        .attr("width", width)
        .attr("height", height);

    // Create a group for the chart content
    const chartGroup = svgContainer.append("g")
        .attr("clip-path", "url(#clip)");

    const bytesFormatter = bytes => `${d3.format(",")(bytes)} (${formatBytes(bytes)})`;
    const tooltipFields = {
        "name": d => d.name,
        "part_type": d => d.part_type,
        "modification_time": d => d.modification_time,
        "marks": d => d3.format(",")(d.marks),
        "marks_bytes": d => bytesFormatter(d.marks_bytes),
        "primary_key_size": d => bytesFormatter(d.primary_key_size),
        "bytes_on_disk": d => bytesFormatter(d.bytes_on_disk),
        "data_compressed_bytes": d => bytesFormatter(d.data_compressed_bytes),
        "data_uncompressed_bytes": d => bytesFormatter(d.data_uncompressed_bytes),
        "blocks": d => d3.format(",")(d.blocks),
        "rows": d => d3.format(",")(d.rows),
        "level": d => d.level,
    };

    // Append rectangles for parts
    const parts = chartGroup.selectAll("rect.part")
        .data(data)
        .enter()
        .append("rect")
        .attr("class", "part")
        .attr("x", d => pxl(xScale(d[leftSelector])))
        .attr("y", d => pxt(yScale(d[ySelector]) - part_dy))
        .attr("width", d => pxr(xScale(d[rightSelector]) - xScale(d[leftSelector])))
        .attr("height", d => pxb(part_dy))
        .attr("fill", d => colorScale(d[colorSelector]))
        .on("click", function(event, d) {
            // Stop propagation to prevent triggering other click events
            event.stopPropagation();

            console.log("CLICKED", d);

            // Generate table rows
            const rows = Object.keys(tooltipFields).map(field => `
                <tr><td><strong>${field}</strong></td><td>${tooltipFields[field](d)}</td></tr>
            `).join("");

            // Set the content of the tooltip
            tooltip
                .style("left", `${event.pageX + 10}px`)
                .style("top", `${event.pageY + 10}px`)
                .html(`
                    <table>${rows}</table>
                `)
                .transition()
                .duration(200)
                .style("opacity", 1);

            // Get the tooltip's dimensions
            const tooltipNode = tooltip.node();
            const tooltipRect = tooltipNode.getBoundingClientRect();
            const tooltipWidth = tooltipRect.width;
            const tooltipHeight = tooltipRect.height;

            // Get the viewport dimensions
            const pageWidth = window.innerWidth;
            const pageHeight = window.innerHeight;

            // Define offsets
            const offset = 10; // Distance from cursor

            // Calculate initial position (right and below the cursor)
            let left = event.pageX + offset;
            let top = event.pageY + offset;

            // Get the mouse position relative to the page
            const [mouseX, mouseY] = d3.pointer(event);

            // Adjust horizontal position if tooltip overflows to the right
            if (mouseX + offset + tooltipWidth > pageWidth) {
                left = event.pageX - tooltipWidth - offset;
            }

            // Adjust vertical position if tooltip overflows to the bottom
            if (mouseY + offset + tooltipHeight > pageHeight) {
                top = event.pageY - tooltipHeight - offset;
            }

            // Further adjust if tooltip goes beyond the left edge
            if (left < 0) {
                left = offset;
            }

            // Further adjust if tooltip goes beyond the top edge
            if (top < 0) {
                top = offset;
            }

            // Position the tooltip
            tooltip
                .style("left", `${left}px`)
                .style("top", `${top}px`)
                .style("visibility", "visible") // Make it visible
                .transition()
                .duration(200)
                .style("opacity", 1);
        });

    // Create selectors for y-axis, x-axis, and color-axis
    const yOptions = ["bytes_on_disk", "data_compressed_bytes", "data_uncompressed_bytes", "blocks", "rows"];
    const xOptions = ["blocks", "bytes_on_disk", "rows"];
    const colorOptions = ["level", "blocks", "rows", "bytes_on_disk", "data_compressed_bytes", "data_uncompressed_bytes"];

    // X selector
    const xSelectorCol = selectorsContainer
        .append("div")
        .attr("class", "me-3");
    xSelectorCol.append("label")
        .attr("class", "form-label small me-2")
        .text("X: ");
    const xSelectorElement = xSelectorCol.append("select")
        .attr("class", "form-select form-select-sm me-2")
        .on("change", function() {
            xSelector = this.value;
            leftSelector = `left_${xSelector}`;
            rightSelector = `right_${xSelector}`;

            // Rescale axis
            const { minXValue, maxXValue } = computeXAggregates(data, leftSelector, rightSelector);
            xScale.domain([minXValue, maxXValue]);

            // Update the formatter
            const xAxisFormatter = getFormatter(xSelector);

            // Apply transitions to rectangles
            parts.transition().duration(1000)
                .attr("x", d => pxl(xScale(d[leftSelector])))
                .attr("width", d => pxr(xScale(d[rightSelector]) - xScale(d[leftSelector])))

            // Update axes with transitions
            xAxisGroup.transition().duration(1000)
                .call(d3.axisBottom(xScale).tickFormat(xAxisFormatter));
        });
    xSelectorElement.selectAll("option")
        .data(xOptions)
        .enter()
        .append("option")
        .text(d => d)
        .attr("value", d => d);

    // Y selector
    const ySelectorCol = selectorsContainer
        .append("div")
        .attr("class", "me-3");
    ySelectorCol.append("label")
        .attr("class", "form-label small me-2")
        .text("Y: ");
    const ySelectorElement = ySelectorCol.append("select")
        .attr("class", "form-select form-select-sm")
        .on("change", function() {
            ySelector = this.value;

            // Rescale axis
            const { minYValue, maxYValue } = computeYAggregates(data, ySelector);
            yScale.domain([Math.max(1, minYValue), Math.pow(2, Math.ceil(Math.log2(maxYValue)))]);

            // Update the formatter
            const yAxisFormatter = getFormatter(ySelector);

            // Apply transitions to rectangles
            parts.transition().duration(1000)
                .attr("y", d => pxt(yScale(d[ySelector]) - part_dy))
                .attr("height", d => pxb(part_dy));

            // Update axes with transitions
            yAxisGroup.transition().duration(1000)
                .call(d3.axisLeft(yScale).tickFormat(yAxisFormatter))
        });
    ySelectorElement.selectAll("option")
        .data(yOptions)
        .enter()
        .append("option")
        .text(d => d)
        .attr("value", d => d);

    // Color selector
    const colorSelectorCol = selectorsContainer
        .append("div")
        .attr("class", "me-3");
    colorSelectorCol.append("label")
        .attr("class", "form-label small me-2")
        .text("Color: ");
    const colorSelectorElement = colorSelectorCol.append("select")
        .attr("class", "form-select form-select-sm")
        .on("change", function() {
            colorSelector = this.value;

            // Rescale color axis
            const { minColorValue, maxColorValue } = computeColorAggregates(data, colorSelector);
            colorScale.domain([minColorValue, maxColorValue]);

            // Apply transitions to rectangles
            parts.transition().duration(0)
                .attr("fill", d => colorScale(d[colorSelector]));
        });
    colorSelectorElement.selectAll("option")
        .data(colorOptions)
        .enter()
        .append("option")
        .text(d => d)
        .attr("value", d => d);

    // Determine the tick step based on maxValue
    const tickStep = determineTickStep(maxXValue - minXValue);

    // Add the x-axis at the bottom of the SVG
    const xAxis = d3.axisBottom(xScale)
        .tickValues(d3.range(minXValue, maxXValue, tickStep))
        .tickFormat(getFormatter(xSelector));
    const xAxisGroup = svgContainer.append("g")
        .attr("transform", `translate(0, ${svgHeight - margin.bottom})`)
        .call(xAxis);

    // Add the y-axis with custom ticks for powers of 2
    const powersOfTwo = Array.from({ length: 50 }, (v, i) => Math.pow(2, i + 1));
    const yAxis = d3.axisLeft(yScale)
        .tickValues(powersOfTwo.filter(d => d >= minYValue && d <= maxYValue))
        .tickFormat(getFormatter(ySelector));
    const yAxisGroup = svgContainer.append("g")
        .attr("transform", `translate(${margin.left}, 0)`)
        .call(yAxis);

    // Zoom functionality
    const zoom = d3.zoom()
        .scaleExtent([1, 1000000])
        .translateExtent([[margin.left, 0], [svgWidth - margin.right, svgHeight]])
        .on("zoom", zoomed);

    svgContainer.call(zoom);

    function zoomed(event) {
        // Update x-scale with the zoom transform
        const transform = event.transform;
        const newXScale = transform.rescaleX(xScale);

        // Update axes and rectangles with the new scale
        xAxisGroup.call(xAxis.scale(newXScale));
        parts.attr("x", d => pxl(newXScale(d[leftSelector])))
            .attr("width", d => pxr(newXScale(d[rightSelector]) - newXScale(d[leftSelector])));
    }
}

let add_http_cors_header = (location.protocol != 'file:');

if (!document.getElementById('url').value) {
    document.getElementById('url').value = location.protocol != 'file:' ? location.origin : 'http://localhost:8123/';
}

if (!document.getElementById('user').value) {
    let user = 'default';

    const current_url = new URL(window.location);
    /// Substitute user name if it's specified in the query string
    const user_from_url = current_url.searchParams.get('user');
    if (user_from_url) {
        user = user_from_url;
    }
    document.getElementById('user').value = user;
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

let canvas = document.getElementById('canvas');

let loading = false;
let stopping = false;
let query_controller = null;

async function load() {
    canvas.innerHTML = '';

    const host = document.getElementById('url').value;
    const user = document.getElementById('user').value;
    const password = document.getElementById('password').value;

    let url = `${host}?default_format=JSONEachRow&enable_http_compression=1`

    if (add_http_cors_header) {
        // For debug purposes, you may set add_http_cors_header from the browser console
        url += '&add_http_cors_header=1';
    }

    if (user) {
        url += `&user=${encodeURIComponent(user)}`;
    }
    if (password) {
        url += `&password=${encodeURIComponent(password)}`;
    }

    const query = document.getElementById('query').value;

    const errorDiv = document.getElementById('query-error');
    errorDiv.style.display = 'none';
    errorDiv.textContent = '';

    let response, reply;
    let error = '';
    let controller = null;
    try {
        loading = true;
        document.getElementById('play').value = '⏹';

        query_controller = new AbortController();
        response = await fetch(url, {
            method: "POST",
            body: query,
            signal: query_controller.signal,
            headers: { 'Authorization': 'never' }
        });
    
        if (!response.ok) {
            const reply = await response.text();
            console.log(reply);
            for (let line of reply.split('\n')) {
                if (line.startsWith(`{"exception":`)) {
                    throw new Error(`HTTP Status: ${response.status}. Error: ${JSON.parse(line).exception}`);
                }
            }
            throw new Error(`HTTP Status: ${response.status}. Error: ${reply.toString()}`);
        }

        const reader = response.body.getReader();
        const decoder = new TextDecoder();

        let buffer = '';
        while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            if (stopping) {
                stopped = true;
                break;
            }

            buffer += decoder.decode(value, { stream: true });

            let lines = buffer.split('\n');

            for (const line of lines.slice(0, -1)) {
                if (stopping) {
                    stopped = true;
                    break;
                }
                const data = JSON.parse(line);
                await update(data);
            };

            buffer = lines[lines.length - 1];
        }
    } catch (e) {
        console.log(e);
        if (e instanceof TypeError) {
            error = "Network error";
        } else if (e.name === 'AbortError') {
            error = "Query was cancelled";
        } else {
            error = e.toString();
        }        
    }

    loading = false;
    stopping = false;
    query_controller = null;
    document.getElementById('play').value = '▶';

    if (error) {
        errorDiv.textContent = error;
        errorDiv.style.display = 'block';
    } else {
        await visualize();
    }
}

function stop() {
    stopping = true;
    if (query_controller) {
        query_controller.abort();
    }
}

document.getElementById('play').addEventListener('click', _ => {
    if (loading) {
        stop();
    } else if (stopping) {
    } else {
        load();
    }
});

let all = {};

async function update(data) {
    // console.log(data);
    const {database, table, partition} = data;
    const key = database + "." + table + ":" + partition;
    if (!(key in all))
        all[key] = [];
    all[key].push(data);
}

async function visualize() {
    for (const key in all) {
        console.log(key);
        visualizeMergeTree(key, all[key]);
    }
}

// Create tooltip div and hide it initially
const tooltip = d3.select("body").append("div")
    .attr("class", "tooltip");

// Hide tooltip when clicking anywhere else on the page
d3.select("body").on("click", function() {
    tooltip.transition()
        .duration(200)
        .style("opacity", 0);
});

</script>
</body>
</html>
